package com.gaojinqi.lock.redis;

import com.gaojinqi.lock.AbstractDistributedLock;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.lang.NonNull;
import org.springframework.util.CollectionUtils;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;

/**
 * Redis 锁 Lua 脚本实现，可自动续租 <br/>
 * 可用于单机、集群，只保证一定的一致性    <br/>
 * <p>
 * <p>
 * 注意：集群时可能出现一致性问题
 * 1.主从异步复制：主节点把锁未同步到从节点就挂了，从节点选举成主节点，则锁会永久丢失； <br/>
 * 2.集群脑裂：发生网络分区时，重新选举，在集群把这个旧主节点变成一个新的从节点之前，客户端未更新路由表，将锁在发生给旧的主节点，导致锁丢失
 *
 * @author gaojinqi
 * @version 1.0
 * @since 2020年07月7日
 */
@Slf4j
public class RedisRuaLock extends AbstractDistributedLock {

    @NonNull
    private StringRedisTemplate redisTemplate;

    public RedisRuaLock(@NonNull StringRedisTemplate redisTemplate) {
        this(redisTemplate, null);
        this.redisTemplate = redisTemplate;
    }

    public RedisRuaLock(@NonNull StringRedisTemplate redisTemplate, Executor executor) {
        super(executor);
        this.redisTemplate = redisTemplate;
        initScript();
    }

    private static Long SUCCESS_CODE = 1L;

    private static String SETNX_LUA = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
    private static String CAS_LUA = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('pexpire',KEYS[1], ARGV[2]) else return 0 end";
    private static String CAD_LUA = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";

    private static RedisScript<Long> SETNX_SCRIPT = new DefaultRedisScript<>(SETNX_LUA, Long.class);
    private static RedisScript<Long> CAS_SCRIPT = new DefaultRedisScript<>(CAS_LUA, Long.class);
    private static RedisScript<Long> CAD_SCRIPT = new DefaultRedisScript<>(CAD_LUA, Long.class);

    @Override
    public boolean setIfAbsent(String realKey, String token, long expireTime, TimeUnit timeUnit) {
        Long result = redisTemplate.execute(
                SETNX_SCRIPT, Arrays.asList(realKey), token, String.valueOf(timeUnit.toMillis(expireTime)));
        return Objects.equals(SUCCESS_CODE, result);
    }

    @Override
    public boolean compareAndSet(String realKey, String token, long expireTime, TimeUnit timeUnit) {
        Long result = redisTemplate.execute(
                CAS_SCRIPT, Arrays.asList(realKey), token, String.valueOf(timeUnit.toMillis(expireTime)));
        return Objects.equals(SUCCESS_CODE, result);
    }

    @Override
    public boolean compareAndDelete(String realKey, String token) {
        Long result = redisTemplate.execute(
                CAD_SCRIPT, Arrays.asList(realKey), token);
        return Objects.equals(SUCCESS_CODE, result);
    }

    @Override
    public boolean compare(String realKey, String token) {
        return Objects.equals(redisTemplate.opsForValue().get(realKey), token);
    }

    /**
     * 哈希标签（Hash Tag）是确保两个键都在同一个哈希槽里的一种方式
     *
     * @param keys 多个key
     * @return 哈希标签
     */
    protected String hashTag(String... keys) {
        if (keys == null || keys.length == 0) {
            return "{default}";
        }

        int h = 0;
        for (String key : keys) {
            int temp = key.hashCode();
            temp ^= temp >>> 16;
            h ^= temp;
        }
        // 只保留8位
        h >>>= (16 + 8);
        return "{" + h + "}";
    }

    /**
     * 初始化Lua脚本
     */
    private void initScript() {
        // Lua脚本运行之前都需要加载一次，为了减少网络开销，可在初始化时就将lua脚本预加载到redis中
        redisTemplate.execute((RedisCallback<Boolean>) connection -> {
            try {
                String setnxScript = SETNX_SCRIPT.getScriptAsString();
                String casScript = CAS_SCRIPT.getScriptAsString();
                String cadScript = CAD_SCRIPT.getScriptAsString();
                List<Boolean> exists = connection.scriptExists(setnxScript, casScript, cadScript);
                if (!CollectionUtils.isEmpty(exists)) {
                    if (exists.size() >= 1 && (exists.get(0) == null || !exists.get(0))) {
                        connection.scriptLoad(setnxScript.getBytes());
                    }
                    if (exists.size() >= 2 && (exists.get(1) == null || !exists.get(1))) {
                        connection.scriptLoad(casScript.getBytes());
                    }
                    if (exists.size() >= 3 && (exists.get(2) == null || !exists.get(2))) {
                        connection.scriptLoad(cadScript.getBytes());
                    }
                }
                return true;
            } catch (Exception e) {
                log.warn("load lua script occured an exception", e);
            }
            return false;
        });
    }

}
